Skip to content

Simplify StringBuilder indexer to reuse FindChunkForIndex#130554

Merged
tannergooding merged 1 commit into
dotnet:mainfrom
tannergooding:tannergooding-solid-memory
Jul 13, 2026
Merged

Simplify StringBuilder indexer to reuse FindChunkForIndex#130554
tannergooding merged 1 commit into
dotnet:mainfrom
tannergooding:tannergooding-solid-memory

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Indexing into a StringBuilder is O(number of chunks) by nature (see #64545), but the get/set indexer implementations were also doing more per-hop work than necessary:

  • Each hop of the walk did two comparisons (indexInBlock >= 0 and indexInBlock >= m_ChunkLength).
  • An out-of-range (or negative) index was validated by walking the entire chunk list until m_ChunkPrevious == null, i.e. the error path was itself O(chunks).
  • The setter constructed new ArgumentOutOfRangeException(nameof(index), SR.ArgumentOutOfRange_IndexMustBeLess) inline, bloating the property body and blocking inlining.

Since chunks tile [0, Length) with no gaps and Length is O(1), a single up-front (uint)index >= (uint)Length check is sufficient to validate the index (covering both negative and too-large in one unsigned compare). After that, the chunk containing the index is exactly what the existing FindChunkForIndex helper returns, so the walk becomes a single comparison per hop and the helper is reused rather than open-coded a third time. The throws are routed through ThrowHelper so the property bodies stay small enough to inline.

No behavior change: the getter still throws IndexOutOfRangeException and the setter still throws ArgumentOutOfRangeException with the same index parameter name and resource.


Benchmark

Both indexer variants modeled side-by-side over a faithful chunk structure (_Start = index near 0 / max hops, _End = index in the head chunk / 0 hops, ChunkCount = number of chunks). .NET 10.0.9, X64 RyuJIT AVX2:

Scenario 1 chunk 4 16 64
Get near end (common) old→new 0.98→0.84 (−14%) 0.98→0.83 (−15%) 0.97→0.84 (−14%) 0.96→0.85 (−12%)
Get near middle old→new 0.98→0.84 1.46→1.06 (−27%) 4.56→4.35 25.86→25.04
Get near start old→new 1.71→0.20 2.43→2.09 (−14%) 10.74→10.88 83.47→63.82 (−24%)
Set near start old→new 2.09→1.34 (−36%) 3.47→3.15 (−9%) 13.02→13.30 68.77→68.93

(ns, mean; zero allocations either way.)

Highlights:

  • Getter is faster or equal on every case, including the common single-chunk / head-chunk path (−14%), because FindChunkForIndex inlines into the property on modern RyuJIT.
  • Many-chunk walk (the flagged anti-pattern) drops 24% at 64 chunks, and variance collapses (StdDev 3.37ns → 0.03ns; the old shape was multimodal).
  • Setter improves the common case substantially (−36% at 1 chunk) from out-of-lining the throw, and is within noise of the old code on deep walks (both do the same traversal there).

I've left FindChunkForIndex itself unchanged — it's already a minimal backward pointer-chase, and the remaining cost on deep walks is memory-latency-bound traversal, which is a growth-algorithm concern out of scope for this change.

Note

This PR description was drafted by GitHub Copilot on my behalf.

The get/set indexers open-coded a chunk walk that did two comparisons per
hop and traversed the entire chunk list to validate an out-of-range index.

Hoist a single `(uint)index >= (uint)Length` bounds check up front so the
out-of-range path is O(1) and each hop of the walk is a single comparison,
then reuse the existing `FindChunkForIndex` helper for the traversal. Route
the throws through `ThrowHelper` so the property bodies stay small enough to
inline.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 11, 2026 18:42
@tannergooding

Copy link
Copy Markdown
Member Author

@EgorBot -linux_amd -osx_arm64

using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);

public class Bench
{
    [Params(8, 64, 512, 4096)]
    public int Length { get; set; }

    private StringBuilder _presized = null!;   // single chunk
    private StringBuilder _grown = null!;       // many chunks via front-inserts

    [GlobalSetup]
    public void Setup()
    {
        _presized = new StringBuilder(Length);
        for (int i = 0; i < Length; i++) _presized.Append((char)('a' + (i % 26)));

        _grown = new StringBuilder();
        for (int i = 0; i < Length; i++) _grown.Insert(0, (char)('a' + (i % 26)));
    }

    [Benchmark]
    public int Sum_Presized()
    {
        StringBuilder sb = _presized;
        int sum = 0;
        for (int i = 0; i < sb.Length; i++) sum += sb[i];
        return sum;
    }

    [Benchmark]
    public int Sum_Grown()
    {
        StringBuilder sb = _grown;
        int sum = 0;
        for (int i = 0; i < sb.Length; i++) sum += sb[i];
        return sum;
    }

    [Benchmark]
    public void Set_Grown()
    {
        StringBuilder sb = _grown;
        for (int i = 0; i < sb.Length; i++) sb[i] = 'x';
    }
}

Note

This comment was drafted by GitHub Copilot on my behalf.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors StringBuilder’s char this[int index] indexer to do a single up-front bounds check against Length, then reuse the existing FindChunkForIndex helper to locate the backing chunk, and route exception creation through ThrowHelper. This keeps the indexer’s semantics the same while reducing per-hop work and keeping the getter/setter bodies smaller.

Changes:

  • Replace open-coded backward chunk walk in the indexer with FindChunkForIndex(index).
  • Add a single (uint)index >= (uint)Length bounds check to cover negative and too-large indices without an O(chunks) validation path.
  • Use ThrowHelper for IndexOutOfRangeException (getter) and ArgumentOutOfRangeException with the index parameter and SR.ArgumentOutOfRange_IndexMustBeLess resource (setter).
Show a summary per file
File Description
src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs Simplifies indexer get/set by hoisting bounds checks, reusing FindChunkForIndex, and centralizing throws via ThrowHelper.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 0

@tannergooding tannergooding merged commit 1b6d60c into dotnet:main Jul 13, 2026
147 checks passed
@tannergooding tannergooding deleted the tannergooding-solid-memory branch July 13, 2026 13:26
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 11.0-preview7 milestone Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants